fix(client)!: oauth.applications.delete resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete - #15675
Conversation
…ts route answers
`ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a
route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with
`SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after
the row had already been committed away server-side. The method had no success
path a caller could observe, and the obvious recovery (retry) failed
DIFFERENTLY, with the route's 404 `not_found`.
Measured end to end, not inherited: real betterAuth + real oauthProvider over
the real ObjectQL adapter, a real signed-up user and session, driven through the
real client with only the socket stood in for.
POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json
· NO content-length header
through the client, before -> REJECTED: SyntaxError
the row, server-side -> ALREADY GONE (get-client answers 404)
through the client, after -> RESOLVED | undefined
Emptiness is detected by READING the body. Both shortcuts were measured and both
are unusable here: the status is 200, not the 204 five other delete surfaces in
this file key off, and the response carries no `content-length` header at all.
A non-empty body is still parsed and its failure still thrown, so the ONLY
behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted"
and "was already gone" are distinguished on the ERROR channel (404 `not_found`,
raised by `this.fetch` before any success value exists), so a synthesised
`{ deleted: true }` would be a shape the wire never sends.
`exported-any-returns.json` loses this method's entry in the same change — the
ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*`
entry is now gone; 35 sites remain open. The `toEqualTypeOf<any>()` pin PR
#15445 left behind for exactly this moment is replaced by
`returnTypePrecisionPins15451`, and the reject/resolve flip — which no
compile-time assertion can observe — is pinned in a new runtime suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
….delete` `**BREAKING**` on two independent axes: the declared return moves off an erased `any` onto `void` (a compile break, though only for reads of a value the promise never produced), and the runtime flips from always-rejecting to resolving, so a caller's `catch` stops firing on success.⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on this changeset until a maintainer settles it. Both legs were measured rather than guessed: type-surface-only REFUSED at predicate 4. A reference is a bare identifier resolved to the FIRST same-named definition, and index.ts declares TEN members named `delete`; the first (line 2397) is unannotated at both revs, so the gate reports "still UNANNOTATED" about a member this diff never touched. Issue #15627, filed off PR #15445 where the same ambiguity cost the `get` member its place in the marker — here it blocks the only member there is. no-migration-prescription mechanically ACCEPTED, and deliberately not taken. ADR-0087's D7 records that #8277 held this exemption on a detector MISS rather than a positive finding, and names that as the pattern the sixth category exists to stop. Taking it here, with the measurement in hand, would repeat it knowingly. Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum closes it for this class in as many words. So the honest state is a loud red on one gate with the reasoning written down, rather than a green held by a category that does not describe this diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0a1ccafb74726260da0453efb58b540bd0f8533d && git checkout 0a1ccafb74726260da0453efb58b540bd0f8533d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a 38d66fea35d6af7038632a5e013516cd415b6e8a && git checkout -B drift-repro dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a && git merge --no-ff 38d66fea35d6af7038632a5e013516cd415b6e8a
node scripts/docs-audit/affected-docs.mjs --json dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a
|
Seat status — the code is done; the block is a governed-surface decision, escalated⛔ Not flipped, not enqueued. The decision has been escalated to #15674, which now carries Why I am not choosing between the four optionsTwo of them edit ⭐ One thing I want on the record because it cuts against my own convenience: option A edits My recommendation to the maintainer is A (repair the addressing), with B as a parallel prose fix — four axes all point the same way, so there is no trade-off to present. The full analysis is on #15674. What you did that I want to name
⛔ Both process notes are defects in MY dispatch, not yours
Generated by Claude Code |
…-0087 ledger The changeset for this branch declared a breaking change and carried no `adr-0087:` disposition marker, so `check-adr-0087-registration` refused it. The maintainer ruling on #15674 (comment 5549251533, 2026-09-05) settled the disposition: this class routes through `registered`, not through any `not-required` category, and the sixth category's text stays as written. - `entries/semantic/18.client-oauth-applications-delete-void.ts` — one `SemanticMigration` for major 18, the open window (`PROTOCOL_VERSION` is 17.0.0). surface: both halves of what a caller of `client.oauth.applications.delete` observes — the declared return, `any` to `void`, and the settle behaviour, reject-on-every-successful-delete to resolve. replacement: no value; the migration is on the settle path, the `catch` that fired on every successful delete now fires only on a real failure. reason: the wire is byte-identical and no `packages/spec` declaration moves, so the ledger is the only channel — and the half that actually ran has NO diagnostic, since a `try`/`catch` around the call compiles identically before and after while its `catch` stops executing. The TS2339 the type move produces names only code that was unreachable. acceptanceCriteria: every `catch` around the call re-read by hand, and the measured populations (this repo: zero production call sites; objectui at the pinned `.objectui-sha`: zero; cloud: NOT MEASURED). - `migrations/registry.ts` — regenerated by `gen:migration-registry` (187 semantic, 161 retired-key, 116 retired-def); never hand-edited. - The changeset's non-claim note becomes `adr-0087: registered client-oauth-applications-delete-void`; the rest of the file is byte-identical and keeps its `**BREAKING**` declaration. Anchors live in a source comment above the type import rather than in the entry's strings: the strings are projected into `spec-changes.json` and `docs/protocol-upgrade-guide.md` when 18 becomes current, and this repo's issue numbers do not resolve for the consumers who read those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Contract review (clause ②) — PASS on content · landing held (CI running + pair declaration) — PR #15675 at head
|
…ot an ADR-0112 envelope
`entries/semantic/18.client-oauth-applications-delete-void.ts` listed "same
ADR-0112 error envelope" among the things this change leaves untouched on the
wire. The operative claim is true — the error bodies do not move — but the
label is wrong for this route. `POST {auth}/oauth2/delete-client` is
better-auth's, and its 404 answers the vendor's FLAT shape,
`{"error_description":"client not found","error":"not_found"}`, not
ObjectStack's nested ADR-0112 envelope. The repo states the distinction
verbatim at
`packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:86`:
"ObjectStack's ADR-0112 envelope nests it; better-auth's flat shape does not".
Worth a correction rather than a follow-up because the string is
consumer-facing: entries prefixed `18.` project into `spec-changes.json` and
`docs/protocol-upgrade-guide.md` when major 18 becomes current, and a reader
told "same ADR-0112 error envelope" would write a nested read against a flat
body. What the entry asserts is unchanged; only the mislabel moves.
`migrations/registry.ts` is regenerated by `gen:migration-registry`, never
hand-edited, and a re-run at this head is a byte no-op — the entry is indexed,
not merely present.
This branch squashes, so its commit messages enter `main` verbatim and this
one carries the current state of the disposition question. The ADR-0087
disposition IS claimed: the changeset carries
`adr-0087: registered client-oauth-applications-delete-void`, and
`check-adr-0087-registration` exits 0 at this head, printing "1
declared-breaking changeset(s), each carrying an ADR-0087 disposition" against
that marker. An earlier message on this branch states the disposition is not
claimed and that the gate is expected to red until a maintainer settles it;
that expectation is superseded — the maintainer settled it as ruling D on
#15674 (2026-09-05), and this class routes through ADR-0087 `registered`.
Measured at this head, exit codes captured by redirect-then-capture:
`gen:migration-registry` re-run byte no-op (`git hash-object` unchanged),
`check:spec-changes` "spec-changes.json is up to date.", `check:upgrade-guide`
"protocol-upgrade-guide.md is up to date.", `check:adr-0087-registration`
self-test (332 assertions) and main both exit 0, the spec migrations suite 117
passed, `@objectstack/spec` typecheck exit 0, and `check:nul-bytes`,
`check:doc-authoring`, `check:spec-parsed-alias` exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
One factual correction on the ledger entry, plus one on this body — head is now
|
Landing provenance —
|
Fixes #15451
ObjectStackClient.oauth.applications.deleteendedreturn res.json()on a route that answers HTTP 200 with a zero-byte body, so it rejected withSyntaxError: Unexpected end of JSON inputon every successful delete — after the row had already been committed away server-side. The method had no success path a caller could observe, and the obvious recovery (retry) failed differently, with the route's 404not_found.The fifth and last method of the
oauth.*family, and the one #14312 / PR #15445 deliberately could not close: that ruling fenced the card to narrowing published return types, and no declared return type can be true while theres.json()call stands.Re-driven, not inherited
The card's premise was re-measured on this branch rather than taken from the card, at
3fd460ee402(the binding commit). RealbetterAuth+ real@better-auth/oauth-providerover the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the realObjectStackClientwith only the socket stood in for (@objectstack/verify's in-process app), against the showcase stack:The three sibling methods answering normally on the same run are the harness's positive control: this is not a probe that failed to reach the route. The 404 after the client threw confirms the nastiest part of the card — the caller sees failure for a completed destructive operation, and the natural retry then produces a second, differently-shaped failure.
The harness was a throwaway; it is not in this diff. Everything it proved is pinned below at a level that does not need a stack boot.
The two open questions the card handed over, answered by measurement
1. What should the method resolve to?
void. The route does distinguish "deleted" from "was already gone" — but on the error channel: a client that is not there answers 404{ error: 'not_found' }, whichthis.fetchalready raises as a throw before any success value exists. The 200 answer carries zero bytes and therefore zero information, so a synthesised{ deleted: true }would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. The vendor agrees from its own side:StrictEndpoint('/oauth2/delete-client', ..., void).2. How should the emptiness be detected? By reading the body. Both shortcuts were measured against the real route and both are unusable:
res.status === 204— the spelling five other delete surfaces in this file usecontent-length === '0'null), not zero''on successWhat it costs the day the route grows a payload: a non-empty body is still parsed, and its failure still thrown — so a malformed response stays loud and the only behaviour this change moves is the zero-byte case. A well-formed payload is validated and discarded under the declared
void; surfacing it would be a deliberate, reviewable widening of the return type, never a silent change of shape. The parse is therefore load-bearing rather than dead code, and it is pinned as such.What is in the diff
packages/client/src/index.tspackages/client/exported-any-returns.jsonoauth.*entry is gonepackages/client/src/oauth-applications-delete.test.tspackages/client/src/return-type-precision.test.tstoEqualTypeOf(any)line PR #15445 left behind "to be replaced when the open decision lands" is replaced byreturnTypePrecisionPins15451.changeset/client-oauth-delete-zero-byte-200.mdminor,**BREAKING**, carrying the ADR-0087 disposition markeradr-0087: registered client-oauth-applications-delete-voidpackages/spec/src/migrations/entries/semantic/18.client-oauth-applications-delete-void.tsPROTOCOL_VERSIONis 17.0.0packages/spec/src/migrations/registry.tspnpm --filter @objectstack/spec run gen:migration-registry(187 semantic, 161 retired-key, 116 retired-def). Never hand-edited; a re-run at the delivered head is a no-op, which is how the entry is proven to be indexedBoth halves the triage comment asked for are here: the ledger entry goes in the same PR as the binding, and there is a pin that the method resolves on a zero-byte 200 — so restoring
res.json()"for symmetry with its three siblings" cannot happen silently.Its positive control is carried too:
automation.trigger, bound by PR #13082, is absent from the ledger, so the zero I am reading is a real absence and not a mis-read of the file.The BREAKING question, answered explicitly
BREAKING on two independent axes, and it makes a published method usable for the first time:
anyontovoid. A read like(await ...delete(id)).deletedstops compiling (TS2339).catchstops firing on success.try { await ...delete(id) } catch {}still compiles and still runs, but its catch block was executing on every successful delete and now executes only on a real failure. Any workaround inside it is now inert.Dropping the
**BREAKING**token is not an available exit: ADR-0087's 2026-08-30 addendum closes it for this class in as many words.ADR-0087 disposition — settled by the maintainer, and the gate is green
This section replaces the "one gate is RED on purpose" block this PR carried until 2026-09-06. That block is no longer true: the disposition question it raised has been ruled on, and
check:adr-0087-registrationnow exits 0.The ruling — D, on #15674 (maintainer, recorded by the director seat 2026-09-05, comment
5549251533): this class routes through ADR-0087registered. A published SDK return-shape move registers in the migration ledger, which already carries@objectstack/cliententries of exactly this class —entries/semantic/17.client-delete-result-success.tsand18.client-meta-reset-result-reset.ts— and which PR #14526 drove end to end for #13079. Theregisteredmarker names a ledger id, not a symbol, so the bare-identifier reference-reader defect (#15627) cannot block it. The sixth category's D8 text is not widened: "the only thing that moved is a type annotation" stays accurate as written, and a diff that also moves the body is not a type-surface-only change — it now has a named home instead.What was done, exactly as the ruling prescribes:
packages/spec/src/migrations/entries/semantic/18.client-oauth-applications-delete-void.ts— oneSemanticMigration, shaped on the two sibling entries the ruling names.surfacecarries both halves of what a caller observes (declared returnanytovoid; settle behaviour reject-on-every-successful-delete to resolve),replacementrecords that there is nothing to move a read to and that the migration is on the settle path,reasongives the argument for registering rather than exempting, andacceptanceCriteriacarries the by-handcatchsweep plus the measured populations. The card, PR, ruling and precedent anchors live in a source comment above the type import rather than in the strings — the strings are projected intospec-changes.jsonanddocs/protocol-upgrade-guide.md, read by consumers who cannot resolve this repo's issue numbers. That placement follows18.client-envelope-convergence-analytics-automation.ts, which does the same.packages/spec/src/migrations/registry.ts— regenerated, never hand-edited.adr-0087: registered client-oauth-applications-delete-void.**BREAKING**andminorare unchanged.Why D and not the routes this PR originally reasoned toward — kept because they are the argument for D, not because either is still live:
not-required (type-surface-only ...)was refused at predicate 4, because a reference is a bare identifier resolved to the first same-named definition and this file declares ten members nameddelete; the first (line 2397) is unannotated at both revs, so the gate reported a true sentence about a member this diff never touches. That is check-adr-0087-registration: atype-surface-onlyreference is a bare identifier resolved to the FIRST same-named definition in the file, so a nested SDK member with a common name (oauth.applications.get) cannot be named — PR #15445 had to leave its fourth narrowed member out of its marker #15627 — a real gate defect, proceeding on its own p1 grading, and explicitly not a precondition of anything here. It is also not the reason D was chosen: the ruling holds that the accurate disposition for this diff was nevertype-surface-onlyin the first place, because the body moved too.not-required (no-migration-prescription)passes mechanically but was deliberately never claimed: ADR-0087's own D7 records PR fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 holding that exemption on a detector miss rather than a positive finding, and names it as the pattern the sixth category exists to stop.docs/adr/**is governed surface. A hand-written waiver on a red required check was rejected as the prose-special-case growth this whole family of cards exists to stop.This PR still touches no governed surface.
docs/adr/**,.claude/**,skills/**,AGENTS.mdandCLAUDE.mdare all absent from the diff — the ruling routes around them by design. The cross-lane touch onpackages/specis the one the ruling authorises in as many words, on the shape #14996 to #14526 used; thedomain:specseat is informed by that comment rather than asked to re-approve. Clause ②yesis already declared on the card, andpackages/spec/src/**is the mechanical floor path leg, so the declaration only gets stronger.Verification — all at
459d7037a58origin/mainwas merged in atfc3fb7c4619(merge only — never rebased, amended or force-pushed) and every number below was re-measured after that merge commit, at the head this PR now delivers. The readings the earlier body carried at3fd460ee402are superseded except where a head is named explicitly.Derived gate union —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run at459d7037a58. Change set: exactly the 7 paths in the table above, three-dot against merge basefc3fb7c46.The one family that did not measure on the first pass was converted into a real measurement rather than reported as a pass:
Two verdicts carry a scoped
NOT MEASUREDline that is a property of the local invocation, not of this diff, and neither is read as a clearance:check-changeset-no-major --base origin/main—✓ This diff introduces no major bump, plusLEVEL AXIS: NOT MEASURED — no clause-② declaration was readable for this PR(nopull_requestpayload exists outside CI). CI reads the declaration.check:entry-nameability—446 call probes across 17 public entries, 0 new unnameable structural mentions, plus a pre-existingNOT MEASURED: no callable export on @objectstack/spec/qa, which is a fact about that entry and not about this change.Outside the 76, and run separately because their silence is a fact about a list rather than about these paths: the 5 artifact-roster families whose roster sits in a directory one of these paths is in (
check-changeset-fixed,check:meta-url-spelling,check:authz-resolver,check:error-code-casing,check:filter-alias-parity) — all exit 0 — and the 10 declared-WIDE-population families CI runs on every PR (check:init-service-contract,check:live-db-isolation,check:meta-type-normalized,check:optional-error-sink,check:resume-authority-declared,check:runner-env-posture,check:settings-bind-window,check:startup-registry-verdict,check:verify-stand-in,check:wildcard-fallthrough) — all exit 0.The gate this PR was blocked on, quoted from its own verdict lines at
459d7037a58:Ledger regeneration is a no-op at the delivered head, which is the proof that the entry is indexed rather than merely present:
Package suites and the ratchet, re-run at the delivered head after rebuilding the dependency closure (
pnpm --workspace-concurrency=2 --filter '@objectstack/client^...' build, exit 0) — the merge moved@objectstack/objectql,@objectstack/runtimeand@objectstack/service-analytics, all of which@objectstack/clientdepends on, so a reading off the pre-mergedistwould have been a reading about a different tree:packages/client/exported-any-returns.jsonis shrink-only and it shrank: 36 open sites to 35, its lastoauth.*entry gone. The 35 above is that same number read back at the delivered head.Every exit code above was captured after redirection to a file, never through a pipe.
Ablation — measured at
3fd460ee402, on the binding commitStamped with its own head deliberately: this leg was run against the binding before the ledger entry and the
origin/mainmerge existed, and it is not re-attributed to the delivered head.Direction predicted in writing first: reverting
index.tsalone, with the guards kept, should turn 4 of the 7 runtime pins red, breaktypecheck, and turn the ledger gate red in the unlisted direction. Observed, all three:The three pins that stayed green are the ones that were green in both states and are labelled as such in the file: the malformed body, the whitespace-only body, and the 404. Restore was under an
EXIT INT TERMtrap using absolute paths, then proven three ways — blob-hash equality with the HEAD blob, an emptygit diff HEAD, and an empty whole-treegit status --porcelain— anddistwas rebuilt afterwards and re-checked for the marker, because the restore leg only restores source.Sequencing
packages/client/src/index.tsis the #12104 family's measured hard serial. It was held by nobody at the ref this branch was cut from, and nothing that landed onorigin/mainin the meantime touched it (re-checked at both merges). Cards #15447 and #12104 queue behind this file.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N